/*
 * Licensed to the http://code.google.com/p/crunchutils under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.crunchutils;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.AbstractFileFilter;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.apache.commons.lang.UnhandledException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.util.Collection;
import java.util.Date;
import java.util.Enumeration;
import java.util.zip.*;

/**
 * Provides utility methods to compress and uncompress files.
 *
 * @author James Selvakumar
 */
public class CrunchZipUtils
{
    private static final Logger logger = LoggerFactory.getLogger(CrunchZipUtils.class);

    /**
     * <p>
     * Compresses the input file without preserving the path of the input file in the zip entry.
     * </p>
     * The input file is deleted after the compression. The output file shall be in the same directory
     * as the input file with sensible defaults.
     *
     * @param inputFile The file to be compressed
     * @return The compressed file
     */
    public static File zipAndDelete(File inputFile)
    {
        return zipAndDelete(inputFile, false);
    }

    /**
     * Compresses and deletes the given file. The output file shall be in the same directory as the
     * input file with sensible defaults.
     *
     * @param inputFile    The file to be compressed
     * @param preservePath If true, the path of the input file is preserved in the zip entry; false otherwise
     * @return The compressed file
     */
    public static File zipAndDelete(File inputFile, boolean preservePath)
    {
        logger.debug("Input file modified date {}", inputFile.lastModified());
        File outputFile = getZipFile(inputFile);
        logger.debug("Output file modified date {}", outputFile.lastModified());
        zipAndDelete(inputFile, outputFile, preservePath);
        return outputFile;
    }

    /**
     * Compresses and deletes the given file without preserving the path of the input file in the zip entry.
     *
     * @param inputFile  The file to be compressed
     * @param outputFile The output file
     */
    public static void zipAndDelete(File inputFile, File outputFile)
    {
        zipAndDelete(inputFile, outputFile, false);
    }

    /**
     * Compresses and deletes the given file.
     *
     * @param inputFile    The file to be compressed
     * @param outputFile   The output file
     * @param preservePath If true, the input file path is preserved in the zip entry; false otherwise
     */
    public static void zipAndDelete(File inputFile, File outputFile, boolean preservePath)
    {
        try {
            zip(inputFile, outputFile, preservePath);
            logger.debug("Deleting the file " + inputFile);
            FileUtils.forceDelete(inputFile);
            logger.debug("File " + inputFile + " deleted successfully!");
        } catch (Exception ex) {
            throw new UnhandledException(ex);
        }
    }

    /**
     * Compresses the given file. The compressed file shall be in the same directory as the input file
     * and shall have the same base name as the input file.
     * <p/>
     * For example, if the input file is "/home/foo/bar.txt", the output file shall be "/home/foo/bar.zip".
     * <p/>
     *
     * @param inputFile    the file to be compressed
     * @param preservePath if true, the path of the input file is preserved in the zip entry; false otherwise
     * @return the compressed file
     * @see #getZipFile(java.io.File)
     * @see #compress(java.io.File, java.io.File, boolean)
     */
    public static File zip(File inputFile, boolean preservePath)
    {
        File outputFile = getZipFile(inputFile);
        zip(inputFile, outputFile, preservePath);
        return outputFile;
    }

    /**
     * Compresses the given file to the output file without preserving the input file path.
     *
     * @param inputFile  the file to be compressed
     * @param outputFile the output file
     */
    public static void zip(File inputFile, File outputFile)
    {
        zip(inputFile, outputFile, false);
    }

    /**
     * Compresses the given file to the output file.
     * <p>
     * Assuming that the input file is "/home/foo/bar.txt" and the output file name
     * is "foo-bar.zip", the zip entry shall be like this if preservePath=true
     * <pre>
     * - foo-bar.zip
     *   - home
     *     - foo
     *       - bar.txt
     * </pre>
     * <p/>
     * If preservePath=false, then the zip entry shall look like,
     * <pre>
     *   - foo-bar.zip
     *     - bar.txt
     *  </pre>
     * </p>
     *
     * @param inputFile    The file to be compressed
     * @param outputFile   The compressed file
     * @param preservePath If true, the path of the input file shall be preserved in the zip entry; false otherwise
     */
    public static void zip(File inputFile, File outputFile, boolean preservePath)
    {
        byte[] buffer = new byte[1024];
        ZipOutputStream zipOutputStream = null;
        logger.debug("Input file: " + inputFile + ", output file: " + outputFile + ", preserve path: " + preservePath);

        try {
            CrunchFileUtils.createParentDir(outputFile);
            zipOutputStream = new ZipOutputStream(new FileOutputStream(outputFile));
            if (inputFile.isDirectory()) {
                Collection<File> filesInDir = getFilesInDir(inputFile, outputFile, true);
                logger.debug("Compressing " + filesInDir.size() + " files in " + inputFile);
                for (File file : filesInDir) {
                    addZipEntry(file, zipOutputStream, preservePath);
                }
            } else {
                logger.debug("Modified input file time {}", new Date(inputFile.lastModified()));
                addZipEntry(inputFile, zipOutputStream, preservePath);
            }
            logger.debug("Zip file " + outputFile + " created successfully!");
        } catch (IOException ex) {
            logger.debug("Error occured while creating the zip file " + outputFile, ex);
            throw new UnhandledException("Error occured while creating the zip file " + outputFile, ex);
        } finally {
            IOUtils.closeQuietly(zipOutputStream);
        }

        //TODO:
        //Refer to the url http://stackoverflow.com/questions/1399126/java-util-zip-recreating-directory-structure
        //and provide support to zip nested directories
    }

    private static Collection<File> getFilesInDir(File inputDir, final File outputFile, boolean recursive)
    {
        //this filter ensures that the outputfile is not listed even if it is inside the input dir
        IOFileFilter excludeOutputFileFilter = new AbstractFileFilter()
        {
            @Override
            public boolean accept(File file)
            {
                return !file.getName().equals(outputFile.getName()) ? true : false;
            }
        };
        if (recursive) {
            //list all the files inside the input dir recursively
            return FileUtils.listFiles(inputDir,
                    FileFilterUtils.andFileFilter(FileFilterUtils.fileFileFilter(), excludeOutputFileFilter),
                    FileFilterUtils.directoryFileFilter());
        } else {
            return FileUtils.listFiles(inputDir, FileFilterUtils.andFileFilter(FileFilterUtils.fileFileFilter(),
                    excludeOutputFileFilter), null);
        }
    }

    private static void addZipEntry(File file, ZipOutputStream zipOutputStream, boolean preservePath)
    {
        FileInputStream fileInputStream = null;
        try {
            fileInputStream = new FileInputStream(file);
            // Add zip entry to output stream.
            if (preservePath) {
                logger.debug("Adding the zip entry " + file.getPath());
                ZipEntry zipEntry = new ZipEntry(file.getPath());
                Date date  = new Date(file.lastModified());
                zipEntry.setTime(date.getTime());
                logger.debug("File modified time {} Zip entry modified time {}", date, new Date(zipEntry.getTime()));
                zipOutputStream.putNextEntry(zipEntry);
            } else {
                logger.debug("Adding the zip entry " + file.getName());
                ZipEntry zipEntry = new ZipEntry(file.getName());
                Date date  = new Date(file.lastModified());
                zipEntry.setTime(date.getTime());
                logger.debug("File modified time {} Zip entry modified time {}", date, new Date(zipEntry.getTime()));
                zipOutputStream.putNextEntry(zipEntry);
            }
            IOUtils.copy(fileInputStream, zipOutputStream);
            zipOutputStream.closeEntry();
        } catch (Exception ex) {
            logger.error("Error occured while zipping the file " + file, ex);
        } finally {
            IOUtils.closeQuietly(fileInputStream);
        }
    }

    /**
     * Returns the zip file object corresponding to the input file.
     *
     * @param inputFile The input file object
     * @return The zip file
     */
    public static File getZipFile(File inputFile)
    {
        return new File(inputFile.getParent() + File.separator + getZipFileName(inputFile));
    }

    /**
     * Returns the zip file name corresponding to the given file
     *
     * @param inputFile The input file
     * @return The zip file name
     */
    public static String getZipFileName(File inputFile)
    {
        return FilenameUtils.getBaseName(inputFile.getName()) + ".zip";
    }

    /**
     * Unzips the given zip file to the output directory
     *
     * @param compressedFile the file to be unzipped
     * @param outputDir      the output directory
     */
    public static void unzip(File compressedFile, File outputDir)
    {
        logger.debug("Unzipping the file " + compressedFile + " to " + outputDir);
        ZipFile zipFile = null;
        try {
            zipFile = new ZipFile(compressedFile);
            Enumeration entries = zipFile.entries();

            CrunchFileUtils.createDirIfNotExists(outputDir);

            while (entries.hasMoreElements()) {
                ZipEntry entry = (ZipEntry) entries.nextElement();
                extractZipEntry(zipFile, entry, outputDir);
//                File file = new File(outputDir, entry.getName());
//                if (entry.isDirectory()) {
//                    logger.debug(entry.getName() + " is a directory, so creating it..");
//                    file.mkdirs();
//                } else {
//                    logger.debug("Extracting the file: " + entry.getName() + ", size: " + entry.getSize());
//                    IOUtils.copy(zipFile.getInputStream(entry), new FileOutputStream(file));
//                }
            }
            zipFile.close();
        } catch (IOException ioe) {
            throw new UnhandledException(ioe);
        }
    }

    /**
     * Unzips the given zip file to the same directory as that of the zip file
     *
     * @param compressedFile the file to be unzipped
     */
    public static void unzip(File compressedFile)
    {
        unzip(compressedFile, compressedFile.getParentFile());
    }

    /**
     * Extracts the given zip entry to the given dir
     *
     * @param zipFile      The zip file
     * @param entry        The zip entry to be extracted
     * @param outputDir The directory to which the zip entry should be extracted
     * @return The extracted file
     */
    public static File extractZipEntry(ZipFile zipFile, ZipEntry entry, File outputDir)
    {
        File targetFile = null;
        try {
            targetFile = new File(outputDir, entry.getName());
            if (!targetFile.exists()) {
                if (!targetFile.getParentFile().exists()) {
                    targetFile.getParentFile().mkdirs();
                }
                logger.debug("Extracting the file {} to {}...", entry.getName(), targetFile);
                FileOutputStream fileOutputStream = new FileOutputStream(targetFile);
                IOUtils.copy(zipFile.getInputStream(entry), fileOutputStream);
                IOUtils.closeQuietly(fileOutputStream);
                targetFile.setLastModified(entry.getTime());
                logger.debug("Last modified date time {}", new Date(targetFile.lastModified()));
            } else {
                logger.debug("File {} has been already extracted", entry);
            }
        } catch (Exception ex) {
            logger.debug("Error occurred while extracting the file", ex);
        }
        return targetFile;
    }

    /**
     * Compresses the given bytes and encodes it in Base64.
     * @param rawBytes The bytes to be compressed
     * @return Compressed string
     * @throws IOException
     * @author http://lifelongprogrammer.blogspot.com/2013/11/java-use-zip-stream-and-base64-to-compress-big-string.html
     */
    public static String compress(byte[] rawBytes) throws IOException
    {
        ByteArrayOutputStream rstBao = new ByteArrayOutputStream();
        GZIPOutputStream zos = new GZIPOutputStream(rstBao);
        zos.write(rawBytes);
        IOUtils.closeQuietly(zos);
        byte[] bytes = rstBao.toByteArray();
        return Base64.encodeBase64String(bytes);
    }

    /**
     * Uncompresses the Base64 encoded string and returns a UTF-8 string.
     * @param compressedBase64EncodedString The string to be decoded and uncompressed
     * @return Uncompressed bytes
     * @throws IOException
     * @author http://lifelongprogrammer.blogspot.com/2013/11/java-use-zip-stream-and-base64-to-compress-big-string.html
     */
    public static byte[] uncompress(String compressedBase64EncodedString) throws IOException
    {
        byte[] uncompressedBytes = null;
        byte[] bytes = Base64.decodeBase64(compressedBase64EncodedString);
        GZIPInputStream zi = null;
        try {
            zi = new GZIPInputStream(new ByteArrayInputStream(bytes));
            uncompressedBytes = IOUtils.toByteArray(zi);
        } finally {
            IOUtils.closeQuietly(zi);
        }
        return uncompressedBytes;
    }

}
